/*
 * Copyright (C) 2000-2013 Silverpeas
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Affero General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * As a special exception to the terms and conditions of version 3.0 of
 * the GPL, you may redistribute this Program in connection with Writer Free/Libre
 * Open Source Software ("FLOSS") applications as described in Silverpeas's
 * FLOSS exception.  You should have received a copy of the text describing
 * the FLOSS exception, and it is also available here:
 * "https://www.silverpeas.org/legal/floss_exception.html"
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU Affero General Public License for more details.
 *
 * You should have received a copy of the GNU Affero General Public License
 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
 */

(function() {
  /**
   * An adapter to access the web resources published as a REST-based services.
   *
   * @typedef RESTAdapter
   * @desc The adapter, when instantiated, accepts as argument respectively the base URI of the
   * targeted Web resource and the well-defined Javascript type into which all received JSON objects
   * will be converted.
   * @param {Angular.Service} $http - an HTTP client for AJAX requesting with a Promise support.
   * @param {Angular.Service} $q - a service to handle the Promises.
   */
  angular.module('silverpeas.adapters').factory('RESTAdapter', ['$http', '$q', function($http, $q) {

    /* process any message identified by an HTTP header.
       returns true if a such message is defined and is processed, false otherwise. */
    function performMessage(headers) {
      let registredKeyOfMessages = headers('X-Silverpeas-MessageKey');
      if (registredKeyOfMessages) {
        notyRegistredMessages(registredKeyOfMessages);
        return true;
      }
      return false;
    }

    function _fetchData(data, convert, headers) {
      let result = (convert ? convert(data) : data);
      if (result instanceof Array) {
        let maxlength = headers('X-Silverpeas-Size');
        if (maxlength) {
          result.maxlength = maxlength;
        }
      }
      performMessage(headers);
      return result;
    }

    function _error(data, status, headers) {
      if (!performMessage(headers) && status) {
        if (status > 0) {
          let error = status;
          let messages = data;
          if (typeof data === 'object') {
            if (data.errorMessage) {
              messages = data.errorMessage;
            }
            if (data.status && data.status.code && data.status.message) {
              error = data.status.code + ', ' + data.status.message;
            }
          }
          console.error("Error: " + "[ " + error + " ]" + "[ " + messages + " ]");
        } else {
          // Maybe a request closed before the end, from the user navigation
        }
      } else if (typeof window.console !== 'undefined') {
        console.warn("An unknown and unexpected error occurred");
      }
    }

    function _http(config, data, convert) {
      if (this.sessionKey) {
        config.headers = extendsObject({'X-Silverpeas-Session' : this.sessionKey}, config.headers);
      }
      if (data instanceof FormData) {
        config.headers =
            extendsObject({'Content-Type' : undefined}, config.headers);
        config.data = data;
      } else if (typeof data === 'object') {
        config.headers =
            extendsObject({'Content-Type' : 'application/json; charset=UTF-8'}, config.headers);
        config.data = data;
      } else if (data) {
        config.headers =
            extendsObject({'Content-Type' : 'application/json; charset=UTF-8'}, config.headers);
        config.data = "" + data;
      }
      let deferred = $q.defer();
      $http(config).
      then(function(response) {
        let result = _fetchData(response.data, convert, response.headers);
        this.sessionKey = response.headers('X-Silverpeas-Session');
        deferred.resolve(result);
      }.bind(this), function(response) {
        if (response.headers) {
          let responseData = response.data;
          _error(responseData, response.status, response.headers);
        }
        deferred.reject(responseData);
      });
      return deferred.promise;
    }

    function _get(url, convert) {
      let _realGet = function(url, convert) {
        return _http.call(this, {method: 'GET', url: url}, undefined, convert);
      }.bind(this);
      let urls = new UrlParamSplitter(url).getUrls();
      if (urls.length > 1) {
        let promises = [];
        urls.forEach(function(url) {
          promises.push(_realGet(url, convert));
        });
        // All promises are verified, and the promise of this method is resolved after the last
        // one of queries is performed.
        return synchronizePromises.call($q, promises, function(promiseData, resolvedResultData) {
          resolvedResultData.fromRequestSplitIntoSeveralAjaxCalls = true;
          Array.prototype.push.apply(resolvedResultData, promiseData);
        }, []);
      }
      return _realGet(urls[0], convert);
    }

    function _post(url, data, convert) {
     return  _http.call(this, {method: 'POST', url: url}, data, convert);
    }

    function _put(url, data, convert) {
     return  _http.call(this, {method: 'PUT', url: url}, data, convert);
    }

    function _delete(url, data, convert) {
      return _http.call(this, {method : 'DELETE', url : url}, data, convert);
    }

    /**
     * @constructor - the constructor of the type RESTAdapter.
     * @param {string} url - the base URL at which the target web resource is located and from which
     * all further requests will be sent.
     * @param {function} converter - a function to convert a JSON representation of a resource to
     * a well-typed object.
     */
    let RESTAdapter = function(url, converter) {
      this.sessionKey = undefined;
      this.url = url;
      this.converter = converter;
    };

    /**
     * Posts the specified object in JSON either at the base URL for which this adapter was
     * instantiated or at a specified URL.
     * @param {string}[url] - optionally the URL at which the object has to be posted. If the URL is
     * not passed as parameter, then the base URL defined in this adapter will be used.
     * @param {object} - the object to push
     * @returns {promise|a.fn.promise} - the new created resource.
     */
    RESTAdapter.prototype.post = function() {
      let requestedUrl = this.url;
      let data = arguments[0];
      if (arguments.length > 1) {
        requestedUrl = arguments[0];
        data = arguments[1];
      }
      return _post.call(this, requestedUrl, data, this.converter);
    };

    /**
     * Puts the specified object in JSON either at the base URL for which this adapter was
     * instantiated or at a specified URL.
     * @param {string}[url] - optionally the URL at which the object has to be posted. If the URL is
     * not passed as parameter, then the base URL defined in this adapter will be used.
     * @param {object} - the object to push
     * @returns {promise|a.fn.promise} - the new created resource.
     */
    RESTAdapter.prototype.put = function() {
      let requestedUrl = this.url;
      let data = arguments[0];
      if (arguments.length > 1) {
        requestedUrl = arguments[0];
        data = arguments[1];
      }
      return _put.call(this, requestedUrl, data, this.converter);
    };

    /**
     * Deletes the specified object in JSON either at the base URL for which this adapter was
     * instantiated or at a specified URL.
     * @param {string}[url] - optionally the URL at which the object has to be posted. If the URL is
     * not passed as parameter, then the base URL defined in this adapter will be used.
     * @param {object} - the object to push
     * @returns {promise|a.fn.promise} - the new created resource.
     */
    RESTAdapter.prototype["delete"] = function() {
      let requestedUrl = this.url;
      let value = arguments[0];
      if (arguments.length > 1) {
        requestedUrl = arguments[0];
        value = arguments[1];
      }
      return _delete.call(this, requestedUrl, value, this.converter);
    };

    /**
     * From the specified object, builds and returns the criteria to apply with a further request.
     * The criteria will be used to build the query part of the request URL.
     * @returns {hashtable} - a hash of key-values criterion
     */
    RESTAdapter.prototype.criteria = function() {
      let criteria = null;
      if (arguments && arguments.length > 0) {
        criteria = {};
        for (var i = 0; i < arguments.length; i++) {
          var obj = arguments[i];
          if (obj && typeof obj === 'object') {
            for (var prop in obj) {
              if (prop === 'page' && obj.page.number && obj.page.size)
                criteria.page = obj.page.number + ';' + obj.page.size;
              else if (prop !== 'page' && obj[prop] !== null && obj[prop] !== undefined)
                criteria[prop] = obj[prop];
            }
          }
        }
      }
      return criteria;
    };

    /**
     * Deletes the resource referred either by the specified identifier or by the specified URL.
     * IE8 does force to not use delete method as it is a forbidden keyword for itself...
     * (delete = forbidden keyword)
     * @param {string} id - either a unique identifier or an URL at which the resource is located.
     * @returns {promise|a.fn.promise} - the response of the remove operation.
     */
    RESTAdapter.prototype.remove = function(id) {
      let deferred = $q.defer();
      let uri = id.trim();
      if (uri.indexOf('/') !== 0 && uri.indexOf('http') !== 0)
        uri = this.url + '/' + uri;
      // IE8 does force to use of $http['delete'] instead of $http.delete...
      // (delete = forbidden keyword)
      $http['delete'](uri).success(function(data, status, headers) {
        deferred.resolve(id);
        performMessage(headers);
      }).error(function(data, status, headers) {
        _error(data, status, headers);
        deferred.reject(id);
      });
      return deferred.promise;
    };

    /**
     * Updates the resource referred either by the specified identifier or by the specified URL with
     * the data passed as second parameter.
     * @param {string} id - either the unique identifier or the URI of the resource to update.
     * @param {object} data - the data with which the resource has to be updated.
     * @returns {promise|a.fn.promise} - the new state of the resource.
     */
    RESTAdapter.prototype.update = function(id, data) {
      let uri = id.trim();
      if (uri.indexOf('/') !== 0 && uri.indexOf('http') !== 0)
        uri = this.url + '/' + uri;
      return _put.call(this, uri, data, this.converter);
    };

    /**
     * Finds the object(s) that match the specified parameters. This function acts as a front-end
     * to the other find-kind methods (findByCriteria and findById).
     * @param parameters - according to the type of the parameter, either an object of the specified
     * identifier is searched, or the objects that match the specified criteria.
     * @returns {promise|a.fn.promise} - either an object or an array of objects matching the
     * parameters.
     */
    RESTAdapter.prototype.find = function(parameters) {
      if (parameters !== null && parameters !== undefined) {
        if (typeof parameters === 'number' || typeof parameters === 'string') {
          return this.findById(this.url, parameters);
        } else if (parameters.url) {
          if (parameters.criteria) {
            return this.findByCriteria(parameters.url, parameters.criteria);
          } else {
            return _get.call(this, parameters.url, this.converter);
          }
        } else {
          return this.findByCriteria(this.url, parameters);
        }
      } else {
        return _get.call(this, this.url, this.converter);
      }
    };

    /**
     * Finds an object from the specified identifier from the specified URL.
     * @param {string} url - the base URL from which the object is looked for.
     * @param {string|number} id - the unique identifier of the object.
     * @returns {promise|a.fn.promise} - an object.
     */
    RESTAdapter.prototype.findById = function(url, id) {
      return _get.call(this, url + '/' + id, this.converter);
    };

    /**
     * Finds the objects that satisfy the specified criteria. The criteria should be built from
     * the criteria method of the adapter.
     * @param {string} url - the base URL from which the objects are searched.
     * @param {hashtable} criteria - the criteria to apply with the search.
     * @returns {promise|a.fn.promise} - an array of objects matching the specified criteria.
     */
    RESTAdapter.prototype.findByCriteria = function(url, criteria) {
      if (!url) {
        console.error('[RESTAdapter#findByCriteria] URL undefined!');
        return null;
      }
      let requestedUrl = url;
      for (let param in criteria) {
        if (criteria[param]) {
          if (criteria[param] instanceof Array) {
            for (let i = 0; i < criteria[param].length; i++) {
              requestedUrl +=
                  (requestedUrl.indexOf('?') < 0 ? '?' : '&') + param + '=' + criteria[param][i];
            }
          } else {
            requestedUrl +=
                (requestedUrl.indexOf('?') < 0 ? '?' : '&') + param + '=' + criteria[param];
          }
        }
      }
      return _get.call(this, requestedUrl, this.converter);
    };

    return {
      /**
       * Gets an instance of a RESTAdapter.
       * @param {string} url - the base URL of the targeted web resource.
       * @param {function} [type] - a function representing a Javascript type that will be used to
       * create a new object for each received JSON data from the targeted web resource.
       * @returns {RESTAdapter}
       */
      get: function(url, type) {
        let newObjectFrom = function(properties) {
          let object = new type.prototype.constructor();
          for (var prop in properties) {
            object[prop] = properties[prop];
          }
          if (typeof object.$onInit === 'function') {
            object.$onInit();
          }
          return object;
        };
        let converter = function(data) {
          var object;
          if (data instanceof Array) {
            object = [];
            for (let i = 0; i < data.length; i++) {
                object.push(newObjectFrom(data[i]));
            }
          } else {
             object = newObjectFrom(data);
          }
          return object;
        };
        return new RESTAdapter(url, converter);
      }
    };
  }]);

  /**
   * Processes all the specified promises and returns a promise that ensures that all specified
   * are well performed.
   * @param promises
   * @param thenHandler
   * @param resolvedResultData
   * @returns {*}
   */
  function synchronizePromises(promises, thenHandler, resolvedResultData) {
    let $q = this;
    let undefined;
    if (!thenHandler) {
      thenHandler = undefined;
    }
    if (!resolvedResultData) {
      resolvedResultData = undefined;
    }
    let deferred = $q.defer();
    if (promises.length === 0) {
      // The case of no data exists is not forgotten
      deferred.resolve(resolvedResultData);
    } else {
      let index = 0;
      let promiseProcessor = function(promiseData) {
        if (thenHandler) {
          thenHandler.call(this, promiseData, resolvedResultData);
        }
        index++;
        if (promises.length === index) {
          // The last promise has been performed
          deferred.resolve(resolvedResultData);
        } else {
          promises[index].then(promiseProcessor);
        }
      };
      promises[index].then(promiseProcessor);
    }
    return deferred.promise;
  }

  let UrlParamSplitter = function(url) {
    let urls = [];
    if (url.length > 2000) {
      let decodedParams = {};
      let hugestParam = {key : '', values : []};
      let pivotIndex = url.indexOf("?");
      let baseUrl = url.substring(0, pivotIndex);
      let splitParams = url.substring(pivotIndex + 1).split("&");
      splitParams.forEach(function(param) {
        let splitParam = param.split("=");
        if (splitParam.length === 2) {
          let key = splitParam[0];
          let value = splitParam[1];
          let params = decodedParams[key];
          if (!params) {
            params = [];
            decodedParams[key] = params;
          }
          params.push(value);
          if (params.length > hugestParam.values.length) {
            hugestParam.key = key;
            hugestParam.values = params;
          }
        }
      });
      delete decodedParams[hugestParam.key];
      let commonParams = '?';
      for (let key in decodedParams) {
        if (commonParams.length > 1) {
          commonParams += '&';
        }
        let params = decodedParams[key];
        commonParams += key + "=" + params.join("&" + key + "=");
      }
      let batchParams = commonParams.length > 1 ? '&' : '';
      hugestParam.values.forEach(function(value){
        if (batchParams.length > 1) {
          batchParams += "&";
        }
        batchParams += hugestParam.key + '=' + value;
        if (batchParams.length > 2000) {
          urls.push(baseUrl + commonParams + batchParams);
          batchParams = commonParams.length > 1 ? '&' : '';
        }
      });
      if (batchParams.length > 1) {
        urls.push(baseUrl + commonParams + batchParams);
      }
    } else {
      urls.push(url);
    }

    this.getUrls = function() {
      return urls;
    };
  }
})();

/**
 * Provider of the RESTAdapter angularjs engine for plain old javascript code.
 * @type {RESTAdapter}
 */
var RESTAdapter = angular.injector(['ng', 'silverpeas', 'silverpeas.adapters']).get('RESTAdapter');
